Skip to content

🐛 Coerce workgraph node attributes to JSON-safe values on save - #798

Merged
elinscott merged 6 commits into
aiidateam:mainfrom
elinscott:json-safe-workgraph-data
Jul 22, 2026
Merged

🐛 Coerce workgraph node attributes to JSON-safe values on save#798
elinscott merged 6 commits into
aiidateam:mainfrom
elinscott:json-safe-workgraph-data

Conversation

@elinscott

@elinscott elinscott commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Problem

Node attributes must survive aiida-core's clean_value, but workgraph data can carry Python objects it rejects, so save_workgraph_data raised when the attributes were stored.

What actually reaches the attribute path with such values is error-handler kwargs, copied verbatim at two sites: a graph-level handler lands in workgraph_error_handlers, a task-level one inside workgraph_data under that task's spec. (An earlier version of this description claimed the trigger was an enum.Enum default in a task function signature — as measured in review, that raises earlier, in general_serializer during to_engine_inputs(), and never reaches the attribute path.)

Change

_ensure_json_safe recursively coerces workgraph data before it is assigned to the process node:

  • Enum members (values and mapping keys) are unwrapped to their .value
  • the lossy str() fallback is gated on clean_value rather than json.dumps: only values clean_value genuinely rejects are stringified, while everything storage coerces itself (set/frozenset to list, numpy scalars to Python scalars, BaseType to its value) passes through untouched
  • any Mapping has its keys coerced — clean_value does not inspect keys, so e.g. a tuple-keyed MappingProxyType otherwise passes the helper and fails in the database driver at store()
  • one-shot iterators are materialized to lists — clean_value exhausts them as a side effect of validation, so an empty list would be stored otherwise

Notes

  • The str() fallback is lossy by design (e.g. arbitrary objects become their str())
  • The .value unwrap is limited to enum.Enum, so unrelated objects that merely expose a .value attribute are not silently unwrapped
  • NaN/inf still fail at store: the float short-circuit precedes the gate; unchanged from main
  • bytes pass through and store as a list of ints, matching what storage does without the helper

Testing

Regression tests in tests/test_utils.py cover:

  • the plain-Enum unwrap, key coercion, and the lossy fallback
  • pass-through of clean_value-coercible values (set, numpy.int64, orm.Int) including an end-to-end store round-trip
  • the non-dict Mapping and iterator cases above
  • the save_workgraph_data wiring through WorkGraph.save() for both error-handler sites; these fail if the _ensure_json_safe call sites are removed

Node attributes must be JSON-serializable, but workgraph data can carry
non-serializable Python objects (e.g. enum defaults picked up from task
function signatures), which made save_workgraph_data raise on store.
Unwrap value-carrying objects recursively and fall back to str().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
elinscott and others added 2 commits July 17, 2026 10:54
Enum dict keys were left uncoerced, so json.dumps still raised
"keys must be str, int, float, bool or None" on store. Add
_ensure_json_safe_key to unwrap/stringify keys. Narrow the value
fallback from any object with a non-callable .value to enum.Enum,
so unrelated objects are stringified rather than silently unwrapped.
Update the docstring to match (str fallback is lossy for set/frozenset).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cover plain-Enum unwrap, str-Enum/IntEnum no-op, enum dict-key
coercion, set/frozenset stringification, narrowed non-enum .value
fallback, and an end-to-end store negative control (raw plain-Enum
dict raises at store; wrapped dict stores and round-trips).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@GeigerJ2 GeigerJ2 self-assigned this Jul 20, 2026
@GeigerJ2

Copy link
Copy Markdown
Contributor

Had a proper look at this, including where the data actually ends up. The approach is right and I'd keep the str() fallback. Two things.

Gate the fallback on clean_value rather than json.dumps. It's the more generous of the two: it turns any non-str iterable into a list, unwraps BaseType, coerces numpy integers. Currently, _ensure_json_safe runs immediately before it, so anything in that gap gets stringified before clean_value ever sees it. Measured through WorkGraph.save() with the value in error-handler kwargs: {1, 2, 3} stored as [1, 2, 3] on main and '{1, 2, 3}' here, numpy.int64(7) as 7 and now '7', orm.Int(3) as 3 and now 'uuid: <uuid> (unstored) value: 3'.

try:
    clean_value(value)
except ValidationError:
    return str(value)
return value

Nothing is given up: everything clean_value genuinely rejects (Enum, datetime, Path, UUID, etc.) fails json.dumps too, so the fallback still catches all of it. Only the value path though, _ensure_json_safe_key should keep its whitelist, since clean_value doesn't look at dict keys at all and a tuple key otherwise reaches the DB and fails there.

MWE: a set in error-handler kwargs, and where clean_value sits
from aiida import load_profile, orm
from node_graph.error_handler import normalize_error_handlers
from aiida_workgraph import WorkGraph, task

load_profile()


@task()
def add(x: int = 1, y: int = 2):
    return x + y


def handle(task, **kwargs):  # never runs, only stored
    return 'retrying'


wg = WorkGraph(
    'mwe',
    error_handlers=normalize_error_handlers(
        {'h': {'executor': handle, 'exit_codes': [1], 'kwargs': {'tags': {1, 2, 3}}}}
    ),
)
wg.add_task(add, name='add1')
wg.save()

stored = orm.load_node(wg.process.pk).base.attributes.get('workgraph_error_handlers')
print(repr(stored['h']['kwargs']['tags']))
main                    [1, 2, 3]      <- list
this PR                 '{1, 2, 3}'    <- str
gated on clean_value    [1, 2, 3]      <- list

clean_value is not being skipped, it still runs on every attribute. It just gets handed a string instead of the original value:

WorkGraph.save()
  aiida_workgraph/engine/workgraph.py   on_create()
  aiida_workgraph/utils/__init__.py     save_workgraph_data()   <- _ensure_json_safe(wgdata) runs here
  aiida_workgraph/orm/workgraph.py      setter()                <- node.workgraph_data = ...
  aiida/orm/nodes/attributes.py         NodeAttributes.set()
  aiida/storage/psql_dos/orm/nodes.py   SqlaNode.set_attribute()
                                          -> clean_value(value)

Nothing exercises the save_workgraph_data path. Every test calls _ensure_json_safe directly on a hand-built dict, so the helper is covered but its wiring isn't: deleting the call at all three sites there leaves tests/test_utils.py green (13 passed). Going through WorkGraph.save() also shows the trigger isn't quite the one in the description: an Enum default in a task signature raises earlier, in general_serializer during to_engine_inputs(). What does reach the attribute is error-handler kwargs, copied verbatim, at two sites: a graph-level handler lands in workgraph_error_handlers, a task-level one inside workgraph_data under that task's spec. Happy to push the tests for both I have locally.

elinscott added a commit to elinscott/aiida-workgraph that referenced this pull request Jul 22, 2026
- values clean_value accepts now pass through instead of being
  stringified (set, frozenset, numpy scalar, BaseType); storage
  coerces them itself on store
- coerce any Mapping, not only dict (a tuple-keyed MappingProxyType
  passed the helper and raised in the database driver at store)
- materialize one-shot iterators (a generator was stored as [])
- exercise the save_workgraph_data wiring through WorkGraph.save()
  for graph-level and task-level error-handler kwargs

Refs aiidateam#798 review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- values clean_value accepts now pass through instead of being
  stringified (set, frozenset, numpy scalar, BaseType); storage
  coerces them itself on store
- coerce any Mapping, not only dict (a tuple-keyed MappingProxyType
  passed the helper and raised in the database driver at store)
- materialize one-shot iterators (a generator was stored as [])
- exercise the save_workgraph_data wiring through WorkGraph.save()
  for graph-level and task-level error-handler kwargs

Refs aiidateam#798 review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@elinscott
elinscott force-pushed the json-safe-workgraph-data branch from 8fc576a to 06120bf Compare July 22, 2026 09:26
elinscott and others added 2 commits July 22, 2026 11:30
- merge str-Enum/IntEnum no-op tests into one parametrized test
- parametrize clean_value-coercible pass-through values and split the
  end-to-end store round-trip into its own test
- parametrize iterator materialization; factories give each run a
  fresh, unconsumed iterator

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Documented a helper no-op only; JSON-native enum members are covered by
the storable-payload test. Remove the now-unused IntEnum fixture class.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@elinscott

Copy link
Copy Markdown
Collaborator Author

Thanks @GeigerJ2 — adopted both points.

Gate: thanks, I wasn't aware of clean_value, that's much nicer. Now {1, 2, 3} stored as [1, 2, 3], numpy.int64(7) as 7, orm.Int(3) as 3.

Wiring: added tests through WorkGraph.save() for both sites and verified they fail when the three _ensure_json_safe call sites are removed. (Edge case spotted by Claude: removing only the workgraph_data_short wrap would still pass, since no test puts a non-storable value where only the short-JSON path sees it. Don't think we need to worry about this.)

Adversarial testing also turned up two further holes, both now fixed:

  • a non-dict Mapping (e.g. MappingProxyType with a tuple key) skipped the dict branch, passed clean_value, and failed at store() in the database driver — the mapping branch now tests Mapping
  • a generator was exhausted by clean_value as a side effect of validation and stored as [] — one-shot iterators are now materialized to lists first

Updated the PR description accordingly.

@GeigerJ2 GeigerJ2 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great, thanks @elinscott!

@elinscott
elinscott merged commit 6aa8b91 into aiidateam:main Jul 22, 2026
6 checks passed
@elinscott
elinscott deleted the json-safe-workgraph-data branch July 22, 2026 14:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants